搜尋框接上 API,第一版寫得很單純:
useEffect(() => {
api.search(keyword).then(setResults);
}, [keyword]);
自己測起來沒問題,上了測試環境之後開始有人說:搜尋結果偶爾跟輸入的字對不上。
重現了幾次,才抓到規律:
打 "rep" → 請求 A 出去
打 "repo" → 請求 B 出去
B 先回來 → 畫面顯示 repo 的結果 ✅
A 後回來 → 畫面被蓋成 rep 的結果 ❌
兩個請求誰先回來,是網路說了算。A 比較早出發,但不保證比較早到——只要它晚一步回來,就會把正確的結果蓋掉。
這就是競態(race condition)。
寫 Angular 這幾年,我從沒遇過這個問題。第一個念頭是:奇怪,Angular 那邊是怎麼處理的?
然後我想起來了——我一直都在寫 switchMap。
只是我從來沒想過,它在幫我擋什麼。
switchMap 到底做了什麼this.results$ = this.keyword$.pipe(
switchMap(k => this.api.search(k)),
);
關鍵就在 "switch" 這個字:上游一來新值,就取消上一個內部訂閱,改接新的。
所以剛剛提到 "repo" 進來的瞬間,"rep" 的請求就被取消了。它不會回來,也不會蓋掉任何東西。競態在源頭就被消滅了。
而且 RxJS 給的不只一個答案,而是四個:
| 運算子 | 行為 | 適合 |
|---|---|---|
switchMap |
取消舊的,只要最新的 | 搜尋、切頁 |
mergeMap |
全部並行,誰先回誰先到 | 互不相干的請求 |
concatMap |
排隊,一個做完才換下一個 | 有順序要求的寫入 |
exhaustMap |
舊的還沒完成,就忽略新的 | 防止重複送出 |
這張表我背得很熟,但以前一直把它們當成「四個不同的運算子」。
現在才看懂:我以為我在選運算子,其實我在選競態策略。
這四個名字,代表面對「新舊請求撞在一起」的四種態度。RxJS 把它變成一個詞彙——你只要決定態度,剩下的交給它。
回到那段有 bug 的程式碼:
useEffect(() => {
api.search(keyword).then(setResults);
}, [keyword]);
keyword 每變一次,effect 就重跑一次,發出一個新請求。
而舊的那個,沒有人管。
原因前幾天都講過了:Promise 一旦建立就回不了頭,它根本沒有取消這個概念(Day 8);而 useEffect 重跑時,React 也不會替你收拾上一輪留下的東西。它只做一件事(Day 4):
在新的 effect 執行之前,先呼叫上一輪的清除函式。
這就是 React 給你的全部工具——一個時機點,一聲「上一輪該收尾了」的通知。
至於要收什麼、怎麼收,也就是 switchMap 原本替我決定的那件事,現在輪到我自己決定。
最簡單的做法不是取消請求,而是取消它的資格。
useEffect(() => {
let ignore = false;
api.search(keyword).then(data => {
if (!ignore) setResults(data);
});
return () => { ignore = true; };
}, [keyword]);
第一次看會覺得有點繞,因為它靠的是閉包。
拆開來看:每一次 effect 執行,都會建立一個屬於自己的 ignore 變數。 類似我們常用Flag去控制開關一樣
打 "rep" → effect 執行 → ignore_A = false,發出請求 A
打 "repo" → 先跑上一輪的清除函式 → ignore_A = true
→ effect 再執行 → ignore_B = false,發出請求 B
B 回來 → 檢查 ignore_B → false → setResults ✅
A 回來 → 檢查 ignore_A → true → 什麼都不做 ✅
清除函式改到的是上一輪的那個 ignore,因為它就是在上一輪的作用域裡建立的;而 A 的 .then() 記住的,也正是同一個變數。
這是 JavaScript 閉包的能力,不是 React 的機制。又一次——React 把責任交還給了JS語言本身。
這個版本的好處是通用:不管用的是 fetch、axios,還是任何回傳 Promise 的東西,都能這樣寫。
壞處是,請求還是跑完了。A 照樣送出、照樣佔頻寬、照樣讓後端算一次、照樣花掉一次 API 額度。我只是在它回來的時候,別過頭不看而已。
畫面是對了,但不夠乾淨。
要真的取消,得靠瀏覽器提供的 AbortController。
useEffect(() => {
const controller = new AbortController();
api.search(keyword, { signal: controller.signal })
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [keyword]);
controller.signal 像一張通行證,交給 HTTP 客戶端;只要呼叫 controller.abort(),那個請求就會真的被中斷。
有三個細節值得特別說。
它不是 React 的東西,也不是 Promise 的東西。 AbortController 是瀏覽器的 API。Promise 本身依然沒有取消的能力——被取消的是底下那個 HTTP 請求,Promise 只是跟著 reject 而已。
所以能不能取消,取決於你的 HTTP 客戶端認不認得 signal。fetch 原生支援,axios 新版也支援;但如果你自己包了一層 API client,記得把 signal 一路傳下去。
取消會讓 Promise reject。 這點最容易被忽略。請求被 abort 之後,.catch() 會收到一個 AbortError——但那不是真的錯誤,而是你自己下的指令。
忘了把它濾掉,畫面就會開始跳錯誤訊息:使用者每多打一個字,就彈出一次「搜尋失敗」。所以那行 if (err.name !== 'AbortError') 不是防禦性程式碼,而是必要的。
這一版才真正對應到 switchMap。 第一版只做到畫面正確,這一版連請求本身都收掉了。
用了幾次,我把它抽成一個 hook:
function useSearch(keyword: string) {
const [results, setResults] = useState<Result[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
api.search(keyword, { signal: controller.signal })
.then(data => {
setResults(data);
setLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') return;
setError(err);
setLoading(false);
});
return () => controller.abort();
}, [keyword]);
return { results, loading, error };
}
能動,我還挺滿意的。用起來只要一行:
const { results, loading, error } = useSearch(keyword);
接著我盤點了一下,它還少了什麼。
沒有 debounce。 使用者打五個字就發五個請求,前四個剛發出去就被取消。結果是對的,但很吵。
沒有快取。 同一個關鍵字搜第二次,一切從頭來過。
沒有重試。 網路稍微抖一下,就是一個錯誤畫面。
只有一種策略。 這個 hook 把 switchMap 的行為寫死了。哪天要做一個「重複送出要忽略」的按鈕,exhaustMap 那套邏輯就得從頭再刻一次。
Angular 那邊是換一個單字,畢竟有RxJS武器庫可以用,我這邊是換一整套實作。
switchMap 之所以能只是一個單字,是因為 RxJS 有一份統一的取消契約。
所有 Observable 都能被 unsubscribe,而且語意很明確:停止接收,順便收拾自己的資源。有了這份共識,運算子才敢替你取消上游——它知道取消一定有效,也知道取消之後會發生什麼。
React 這邊沒有這份契約。
Promise 不能取消,能取消的是底下的 HTTP 請求;AbortController 是瀏覽器的能力,不屬於語言,也不屬於框架;清除函式只給你時機,不告訴你該做什麼。
所以每次遇到競態,我都得重新回答同一個問題:「取消」對這件事來說,到底是什麼意思? 是讓結果失效,還是真的中斷請求?是取消舊的,還是忽略新的?
Angular 給我一個詞彙,我就能表達一整套競態策略;React 則要我每次都把那套策略重寫一遍。
明天繼續:兩個請求要一起等的時候,forkJoin 跟 combineLatest 又該怎麼手刻?